概要
Serverless Framework(typescript)で Lambda Layers で自作モジュールの共通化を図ろうとしたところ、結構苦労してしまったので、メモとして残します。
検証に使ったコードは GitHub に置きました。
環境
今回検証した環境です。
- serverless v3.32.2
- serverless framework のテンプレート;aws-nodejs
- Lambda Node.js 18.x
レイヤーのデプロイまで
layer 用の関数
何の変哲もないテストコードを用意しました。
npm install したモジュールを使うパターンと使わないパターンです。
export const hoge = (fuga = 'fuga') => 'hoge' + fuga;
import dayjs from 'dayjs';
export const now = () => dayjs();
レイヤーを追加してデプロイを試みる
serverless create などの準備は省略します。
layers:
test-layer:
path: layers/test-layer
name: test-layer-${sls:stage}
compatibleRuntimes:
- nodejs18.x
compatibleArchitectures:
- x86_64
serverless framework の Lambda Layers のリファレンスを参考に書いてみましたが、上手くいきません。
Error:
No file matches include / exclude patterns
include、exclude のパスが誤っているようですが、serverless.yml には include も exclude も指定していません。
tsconfig.json の include、exclude も合っていると思いますが・・
serverless-plugin-typescript をダウングレードしてみる
ググっていたらこのような記事があったので、書いてある通りに serverless-plugin-typescript のバージョンを下げてみました。
# ^2.1.5 → ^2.1.1
npm i -D serverless-plugin-typescript@2.1.1
エラー内容が違うので、多分解決はしないだろうと思いましたが、案の定エラーのままでした。
Error:
No file matches include / exclude patterns
esbuild に変えてみる
色々見て、serverless 作成の時のテンプレートを「aws-nodejs-typescript」で作成したときには、「serverless-plugin-typescript」が含まれておらず、変わりに 「serverless-esbuild」 になっていることに気が付きました。
"devDependencies": {
"@serverless/typescript": "^3.0.0",
"@types/aws-lambda": "^8.10.71",
"@types/node": "^14.14.25",
"esbuild": "^0.14.11",
"json-schema-to-ts": "^1.5.0",
"serverless": "^3.0.0",
"serverless-esbuild": "^1.23.3",
"ts-node": "^10.4.0",
"tsconfig-paths": "^3.9.0",
"typescript": "^4.1.3"
},
前は、あったはずなんですが、、変わったんでしょうか。
とういわけで、esbuild をインストールします。
npm i -D serverless-esbuild esbuild
プラグインも変更します。
plugins:
# - serverless-plugin-typescript
- serverless-esbuild
こんなエラーが出てしまったので、、、
yml を見直して修正しました。
package:
individually: true
package は成功したが・・・
ようやく package まで完了しました。
しかしまだ問題があります。
トランスパイルされていません。TS のままです。
通常の関数は JS になっています。
external に’*‘を指定してみましたが、またまた別のエラー。
少し該当のコードを見てみましたが、プロジェクトルートの package.json を readFileSync で読み込んでいるようです。
しかし、エラーを見るとプロジェクトの 1 階層上を見ていました。
根が深そうなのでスルーします。
layer フォルダ、無視してるかも
serverless-esbuild のドキュメントには、以下のような記述がありました。
If you wish to use this plugin alongside non Node functions like Python or functions with images, this plugin will automatically ignore any function which does not contain a handler or use a supported Node.js runtime.
https://github.com/floydspace/serverless-esbuild/tree/master
翻訳すると、「このプラグインはハンドラを含まない関数やサポートされている Node.js ランタイムを使用しない関数を自動的に無視します。」とあります。
ハンドラって Lambda 関数のイベント処理コードのことだったはずなので、Lambda レイヤーはハンドラに含まれないから無視されている?
自力で esbuild
仕方がないので、layer の TS は自力で esbuild で JS にして packge することにしました。
layer 用のディレクトリはこのようになっています。
lib 配下に自作モジュールを置き、nodejs 配下には layer の中で使用する package が含まれています。
公式リファレンスを見ると lib は PATH が通されているようなのでここに配置するようにしました。
というか node_modules の配下は Git 管理から ignore しちゃうのでよろしくない・・・
entryPoint にワイルドカードを指定するため、glob パッケージも追加しました。
npm i glob
build 用スクリプトを用意します。
const { build } = require("esbuild")
const glob = require("glob")
// node_modulesを含めない
const entryPoints = glob.sync('./test-layer/lib/**/*.ts')
build({
entryPoints,
bundle: true,
platform: "node",
outbase: "test-layer",
outdir: "./.layers/test-layer",
allowOverwrite: true,
})
スクリプトはここを参考にしました。
このぐらいのスクリプトであれば JS のままでも問題ないと思うので、このまま Node.js で実行して、ビルドします。
node ./build.js
成功しました。
注意 ネイティブコードライブラリをレイヤーに含める必要がある場合
レイヤーの中に Windows でしか動かないコード、Linux では動かないコードがある場合は動作しないので、Amazon Linux と互換性を持たせる必要があります。
node_modules を win で作成してそのままレイヤーにデプロイしたら動かないとか。
過去にローカルの Win だと動いて Lambda だと動かないみたいなものがあった体験をした気がします。
コピーだけだとあまり意識しないと思うので、注意が必要です。
docker や WSL なんかを使うといいですかね。
Lambda Layer を AWS にデプロイ
ようやくデプロイできます。
serverless のコマンドでデプロイします。
serverless deploy
成功しました。
念のため AWS コンソールにサインインして、確認します。
レイヤーの動作を確認
レイヤー をテストするために、テスト用の Lambda 関数を作成しました。
ランタイムは Node.js (18.x)です。
それで呼び出して見てもモジュールが見つからないというエラー・・・
レイヤーの関数呼び出せない問題
どうやら ES モジュールはレイヤーに対応していないっぽいです。
なんというかちょっと残念。
test 関数は ESM(mjs)で作成されていたので、CJS に書き換えました。
でようやく呼び出しができました。
require のパスを「/opt~~」に変えています。
パスが通っているから直接指定でも大丈夫とのことだったのですが、これでは参照できませんでした。
const hoge = require("hoge")
他の参考記事も同じようにやってるけど何かが違うのでしょうか。。
これに関してはこれ以上探りませんでした。
どちらにしても opt をパスに含めない方法だと TS コンパイルの時にエラーになってしまうので、次のステップで別のパスを指定するようにします。
handler から呼び出し
レイヤーを呼び出すための関数を用意します。
テスト用なので、ハンドラからチェックします。
import { hoge } from '/opt/lib/hoge';
import { now } from '/opt/lib/now';
export const hello = async (event) => {
console.log(hoge());
console.log(now().format('YYYY-MM-DD'));
return "OK";
};
ローカルの場合、「/opt/~~」というパスは存在しないので、tsconfig.json の paths に設定を追加しておきます。
これでエラーにならなくなります。
エディタにエラーが表示される場合は、エディタの再起動をしてみるとエラーが表示されなくなると思います。
"paths": {
"/opt/lib/*": ["./test-layer/lib/*"]
}
レイヤーに存在するモジュールを外部パスとする
このままだとせっかくレイヤーを作っても、esbuild したときにレイヤーのモジュールも トランスパイル後の JS ファイルに含まれてしまいます。
というわけで、serverless-esbuild のビルド処理にプラグインを追加します。
custom:
esbuild:
plugins: plugin.js
こちらの JS を追加し、opt を含む場合は、外部パスとして処理します。
let layyerExternalPlugin = {
name: 'layer-external',
setup(build) {
// レイヤーに含むモジュールを外部パスとする
build.onResolve({
filter: /(opt)/
}, args => {
return {
path: args.path,
external: true
}
})
},
};
module.exports = [layyerExternalPlugin];
これによってトランスパイルされた handler.js では、外部パスを参照するようになります。
var import_hoge = require("/opt/lib/hoge");
var import_now = require("/opt/lib/now");
レイヤーの自作モジュールから参照される node_modules
レイヤーから参照されている node_modules も外部パスにします。
こちらは自力で行う esbuild のビルドスクリプトを変更します。
専用のパッケージをインストールします。
npm i -D esbuild-node-externals
ビルドスクリプトにプラグインを追加します。
// ~~省略~~
const {
nodeExternalsPlugin
} = require('esbuild-node-externals')
build({
entryPoints,
bundle: true,
platform: 'node',
outbase: "test-layer",
outdir: "./.layers/test-layer",
allowOverwrite: true,
target: "node18",
tsconfig: "./tsconfig.json",
plugins: [nodeExternalsPlugin()]
})
デプロイして確認
これでデプロイして確認してみます。
問題なく実行されました。
レイヤーの最新の ARN を Ref で参照する
関数とレイヤーの紐づけは ARN を指定することで可能となっていますが、Lambda レイヤーの ARN はバージョンまで指定しなければなりません。
レイヤーを更新したらバージョンが 1 つインクリメントされていくので、レイヤーの更新の度にレイヤーを使う側のレイヤー情報も更新しなければいけないという罠があります。
ということで CloudFormation の組み込み関数「Ref」を使って最新の arn を指定するようにします。
serverless framework のリファレンスには、TitleCase にして”LambdaLayer”を結合した名前で指定せよと記載があります。
To use a layer with a function in the same service, use a CloudFormation Ref. The name of your layer in the CloudFormation template will be your layer name TitleCased (without spaces) and have LambdaLayer appended to the end. EG:
https://www.serverless.com/framework/docs/providers/aws/guide/layers#using-your-layers
リファレンスの例にある「test」というレイヤーは「TestLambdaLayer」という名前になります。
今回自分が作成したレイヤーは「test-layer」という名前なので、「TestLayerLambdaLayer」という名前になる・・・かと思ったらこれではなかったようです。
Error:
The CloudFormation template is invalid: Template format error: Unresolved resource dependencies [TestLayerLambdaLayer] in the Resources block of the template
一旦普通に arn 指定に戻して serverless package のみ実施し、出来上がった CloudFormation のテンプレートを確認してみました。
"TestDashlayerLambdaLayer": {
"Type": "AWS::Lambda::LayerVersion",
~~省略~~
}
”-“は”Dash”になるわけですね。
省略するのはスペースだけでした。
にしても LambdaLayer と勝手にサフィックスをつけられるのなら、名前にレイヤー入れないほうがいいんだな。。。
サイズ比較
せっかくなので、サイズ比較もしておきました。
handler.ts のサイズを比較しましたが、外部パスにレイヤーにあるモジュールを指定した場合、そうでない場合と比べておよそ 1/10 になりました。
結構なサイズダウンですね。
外部パスにした場合:1,453 バイト
外部パスにしない場合:13,209 バイト
余談
このようなプラグインがありましたけど、安全かどうかわからなかったので、試していません。